Skip to content

Add job categories - #3239

Open
sheddy123 wants to merge 22 commits into
dotnet:masterfrom
sheddy123:job-categories
Open

Add job categories#3239
sheddy123 wants to merge 22 commits into
dotnet:masterfrom
sheddy123:job-categories

Conversation

@sheddy123

@sheddy123 sheddy123 commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Issue #3182
Follow-up to the discussion in #3216, and a step towards #3182.

What this adds

This PR introduces job categories to provide a way to group and filter jobs, similar to [BenchmarkCategory].

  • Added MetaMode.Categories as hidden job metadata, so categories can be used for selection without affecting job IDs, folder names, summaries, or generated source.
  • Added WithCategory and WithCategories for assigning categories in code. Categories are case-insensitive and automatically de-duplicated.
  • Added [JobCategory] for applying categories to all jobs at the class or assembly level.
  • Ensured categories from attributes and code are merged rather than overwritten.
  • Added JobCategoryFilter to select jobs based on their assigned categories.

cc @filzrev @adamsitnik @timcassell

Job categories are the job equivalent of [BenchmarkCategory]: they allow
grouping jobs so that a subset of them can be selected, without relying on
the job Id.

- MetaMode.Categories, a hidden characteristic so that categories don't
  affect the generated job Id, the folder names, the summary nor the code
  generated for the child process
- Job.WithCategory (adds) and Job.WithCategories (overrides), mirroring
  WithEnvironmentVariable/WithEnvironmentVariables
- [JobCategory] attribute, which adds its categories to every job defined
  for the given class or assembly. It's implemented as a mutator job, so
  ImmutableConfigBuilder now merges categories instead of overriding them
- JobCategoryFilter, which selects the benchmarks of the jobs that belong
  to any of the given categories
Comment thread docs/articles/configs/jobs.md Outdated
Replaced [JobCategory] with the Categories property in SimpleJob attributes, allowing multiple categories to be set inline. Updated documentation to clarify that categories do not affect job execution details.
Deleted the JobCategoryAttribute.cs file, including all using directives, class definition, constructors, and logic for applying job categories to jobs via attributes. This removes support for assigning categories to jobs through this attribute.
Refactor JobConfigBaseAttribute to use lazy config initialization and support job categories. Add a Categories property for job filtering and update constructors to accommodate these changes. Improve comments and code clarity.
Previously, job categories were explicitly preserved and re-added after applying mutator jobs to ensure they were not lost. This logic has been removed, and mutators are now applied directly without restoring categories.
Replaced WithTwoJobsAndACategory with WithTwoCategorizedJobs using explicit Categories in SimpleJob attributes. Added tests for category assignment, jobs without categories, category-independent job IDs, and category-based filtering. Introduced new test classes and removed obsolete tests and attributes.
@sheddy123

Copy link
Copy Markdown
Contributor Author

@timcassell
One thing worth knowing since it isn't obvious from the diff: the old attribute's "adds to categories already defined in code" behavior is gone along with it. Categories now come from exactly one place per job whichever attribute or fluent call defined that job.

@sheddy123

Copy link
Copy Markdown
Contributor Author

Hi @timcassell, please any updates on this PR?

@timcassell

Copy link
Copy Markdown
Collaborator

Thanks for the PR! I went through the diff (merge-base b6d2177 .. 1fc3f7f), built the branch, and ran the unit suite — 1082 passed / 0 failed, including all 25 new JobCategoryTests. The findings below were reproduced against that build.

Categories affect job identity but not the job name

src/BenchmarkDotNet/Jobs/MetaMode.cs:20

CategoriesCharacteristic is created with CreateHiddenCharacteristic, which only hides it from the presenters (IsPresentableCharacteristic). JobComparer.Compare walks GetAllCharacteristics(), so it does compare categories.

The result is that two jobs differing only by category survive Distinct(JobComparer.Default) but produce byte-identical benchmark names:

ManualConfig.CreateEmpty()
    .AddJob(Job.Dry.WithCategory("a"))
    .AddJob(Job.Dry.WithCategory("b"))

// two cases, both named:
// B.M: Dry(IterationCount=1, LaunchCount=1, RunStrategy=ColdStart, UnrollFactor=1)

Without a JobCategoryFilter in play, the user silently runs the same job twice, gets two indistinguishable summary rows, and any artifact keyed on the benchmark name (EventPipe traces, per-benchmark exports) collides.

JobsWhichDifferOnlyByCategoriesAreNotConsideredDuplicates currently locks this behavior in. Two ways out: exclude the characteristic from JobComparer, or merge categories when deduplicating.

WithCategories() with an empty array silently doubles the run

src/BenchmarkDotNet/Jobs/JobExtensions.cs:343

The assignment is unconditional, so an empty array still sets the characteristic:

Job.Default.WithCategories().Categories.Count           // 0
Job.Default.WithCategories().HasValue(CategoriesCharacteristic)  // true

So AddJob(Job.Default).AddJob(Job.Default.WithCategories()) yields two DefaultJobs and runs the benchmark twice. That makes job.WithCategories(userSelection.ToArray()) a hazard whenever the selection happens to be empty. Suggest skipping the assignment (or clearing the characteristic) when the deduplicated list is empty.

Same line: WithCategories(null) is legal via params, and currently surfaces as ArgumentNullException: ... (Parameter 'source') thrown from Distinct inside MetaMode.Unique. A guard naming categories would be friendlier.

NullReferenceException on Categories = null

src/BenchmarkDotNet/Attributes/Jobs/JobConfigbaseAttribute.cs:32

Categories.Length is dereferenced without a null check. [DryJob(Categories = null)] is valid C# for a named attribute argument of array type, and throws a bare NullReferenceException out of BenchmarkConverter.TypeToBenchmarks during discovery. Categories is not { Length: > 0 } handles both cases.

Memoized Config silently drops later Categories assignments

src/BenchmarkDotNet/Attributes/Jobs/JobConfigbaseAttribute.cs:30

Config memoizes into config while Categories remains publicly settable, so any assignment after the first Config read is dropped (read Config, set Categories = ["late"], re-read → 0 categories on the same instance). Relatedly, job.WithCategories(Categories) replaces, so a user-derived attribute whose base job already carries categories loses them when a consumer sets Categories. Making Categories init-only, or using append (WithCategory) semantics, would avoid both.

Unrelated whitespace churn

src/BenchmarkDotNet/Configs/ImmutableConfigBuilder.cs:242

After 92f6955, the only change left in this file is two deleted blank lines around copy.Apply(mutatorJob). Worth reverting so the PR touches nothing it doesn't need.

(The underlying behavior change here is fine — Apply skips characteristics the mutator has no value for, and [DryJob(Categories = ["a"])] + [GcServer(true)] correctly yields gcServer=True cats=[a], so removing the explicit preservation code was safe.)

Checked and correct

Categories are properly excluded from JobIdGenerator, FolderPresenter, SourceCodePresenter (nothing leaks to the child process) and from JobCharacteristicColumn.AllColumns, so the "no effect on id/folder/summary" intent holds. WithCore copies rather than mutating the shared static jobs; AddCategories/Unique dedupe case-insensitively keeping the first spelling; JobCategoryFilter's empty-array behavior matches the existing AnyCategoriesFilter; and collection expressions in attribute arguments (Categories = ["a", "b"]) do compile.


This review was generated by Claude (Opus 5) via Claude Code and posted by @timcassell. The findings were reproduced against a local build, but please treat the analysis as a starting point rather than a verdict.

Refactored the Categories property to be init-only and nullable (string[]?), enforcing immutability after initialization. Updated Config property logic to handle null or empty Categories safely. Added remarks to clarify attribute usage and rationale for these changes.
Updated job selection logic to group jobs by equality (excluding categories) and merge their categories, replacing Distinct with JobComparer. Added MergeCategories method to ensure all categories are preserved, preventing mismatches when filtering jobs by category.
The comparison logic for jobs now ignores the MetaMode.CategoriesCharacteristic field. This change prevents jobs that differ only by category from being treated as distinct, ensuring the same job isn't run multiple times under different names.
Updated the WithCategories extension for Job to throw ArgumentNullException if categories is null. This improves error handling and prevents potential runtime exceptions.
Updated the Categories property setter to set the value to null if the input is null or results in an empty set after removing duplicates. This change ensures jobs with no categories are treated the same as those never assigned categories, preventing unnecessary updates during category selection.
Expanded and clarified the BenchmarkDotNet job category test suite. Renamed and rewrote the deduplication test to verify merging of categories. Added tests for ordering (ignoring categories), handling empty and null categories, and correct exception parameter naming. Introduced a new test class for null category scenarios to ensure robust and correct category handling.
@sheddy123

Copy link
Copy Markdown
Contributor Author

Hi @timcassell the finds you outlined have now been fixed.

Addressed the findings around job categories, deduplication, null handling, and configuration mutability.

  • Job identity & category merging: MetaMode.Categories was incorrectly affecting job identity because hidden characteristics are still considered by JobComparer. The comparer now excludes the categories characteristic, while GetRunnableJobs groups equivalent jobs and merges their categories. For example, jobs with categories "a" and "b" now collapse into a single job containing both categories instead of silently losing one.
  • Empty categories: WithCategories() no longer creates a separate job. Empty categories are stored as null, allowing deduplication to work correctly while preserving the documented override behaviour.
  • Null handling: Categories now safely handles null. The nullable-array definition also prevents the previous NullReferenceException scenario. The test uses null! to reproduce the same IL where nullable reference types are disabled.
  • Configuration mutability: Categories is now init-only, preventing late assignments from being silently ignored by the memoized configuration.
  • Argument validation: WithCategories(null) now throws ArgumentNullException with categories as the parameter name.
  • Cleanup: Reverted unrelated whitespace changes. The only remaining ImmutableConfigBuilder change is the intentional grouping and category-merging logic.

@timcassell

Copy link
Copy Markdown
Collaborator

Re-reviewed at c5478c88. All five points from the earlier comment are addressed — four verified fixed by execution, one partly. Build is clean across all TFMs (netstandard2.0 picks up the repo's ArgumentNullException.ThrowIfNull polyfill), unit suite 1087 passed / 4 skipped / 0 failed, JobCategoryTests 30 passed. The explanatory comments you added at each fix are genuinely good.

verified
jobs differing only by category 1 job, categories=[a,b]; filters match a, b, A (case-insensitive), not zzz
equality/hash contract same Id, GetHashCode equal, Equals/Compare consistent — also with an explicit WithId
empty categories HasValue == False, ResolvedId unchanged, two jobs collapse to one
WithCategories(null) throws naming categories
[DryJob(Categories = null)] discovery succeeds, no NRE; init doesn't block a derived attribute; Config still cached

Taking both routes for the identity problem — skipping the characteristic in JobComparer and merging in ImmutableConfigBuilder — was the right call; either alone would have left a hole. Worth noting why the pairing is safe: JobComparer.GetHashCode is obj.Id.GetHashCode(), and categories are excluded from JobIdGenerator, so collapsed jobs share an Id and land in the same GroupBy bucket. Had categories leaked into the Id, the merge would have silently never fired. Whitespace churn is gone (git diff -w is byte-identical to the plain diff). MergeCategories's short-circuit can't be wrong, only conservatively false — a needless copy when categories overlap.

Four things left, none blocking:

1. A mutator carrying categories wipes the target's categories. AddJob(Dry.WithCategory("keep")) + AddJob(Default.WithGcServer(true).WithCategory("mut").AsMutator())Dry[mut]; "keep" is gone, because copy.Apply(mutatorJob) treats the characteristic as an ordinary overwriting one. That's the same "selecting by a category matches nothing" failure MergeCategories was written to prevent, reached via the mutator path. Pre-existing to this round rather than introduced by it, and it only bites users who put categories on a mutator. Marking the characteristic IgnoreOnApply (as IsMutatorCharacteristic is) and merging explicitly would close it.

2. Meta.Categories = null still throws ArgumentNullException("source") — the unhelpful message the new guard was added to remove. The guard is only in the WithCategories extension; the property it delegates to is public. AddCategories(null) has the same shape.

3. WithCategory(null) silently stores a null categoryitems=[<null>], and it survives merging. The array is guarded, the elements are not; a null in the list is a latent NRE for anything that formats or compares categories.

4. JobCategoryFilter has no attribute and no CLI option. The predicate itself is consistent with the rest of the codebase — every filter is inclusion-only, and this one is a near-verbatim AnyCategoriesFilter with Descriptor swapped for Job.Meta. What is inconsistent is the surface: AnyCategoriesFilter has both AnyCategoriesFilterAttribute and --anyCategories, AllCategoriesFilter likewise, but JobCategoryFilter has neither, so the only way to use it is constructing it by hand in a config. Given the headline use case is "run only the net8 job", the missing CLI option is the notable one, and it is much cheaper to add before the API ships.

One related design question worth settling explicitly, since the docs are the place users will look: applying JobCategoryFilter drops every case whose job has no categories. That is fine in the documented example where all three jobs are categorized, but in a config mixing categorized and uncategorized jobs, selecting one category silently removes the uncategorized baseline. Either document that, or adopt the convention that a job with no categories always passes.

Reviewed by Claude (Opus 5), posted by @timcassell.

Updated docs to explain three methods for filtering jobs by category: JobCategoryFilter in code, [JobCategoryFilter] attribute, and --jobCategories argument. Added code and CLI examples. Clarified that filters are inclusion-only and jobs without categories are excluded unless assigned.
Updated documentation to describe the new --jobCategories console argument. This option enables running benchmarks for jobs in specified categories, excluding uncategorized jobs. Changes include updates to the usage list and detailed options section.
Introduced JobCategoryFilterAttribute in BenchmarkDotNet.Attributes to enable filtering benchmarks by job categories. Includes constructors for CLS compliance and category specification, XML documentation, and PublicAPI annotation.
Previously, mutating jobs could overwrite existing categories, leading to loss of category information. Now, the original categories are saved, the mutator is applied, and both sets of categories are combined to ensure correct job selection by category.
Added JobCategories property to CommandLineOptions for filtering benchmarks by job category. Updated UserProvidedFilters logic to recognize JobCategories as a user-provided filter.
Added support for filtering benchmarks by job categories in the configuration parser. If job categories are specified in the options, a JobCategoryFilter is created and applied, enabling selective benchmark execution.
Added a detailed <remarks> section to the JobCategoryFilter class summary. The new remarks clarify that the filter is inclusion-only and explain how benchmarks with uncategorized jobs are handled when filtering by category. No code logic was changed.
Refactored the WithCategories method in BenchmarkDotNet.Jobs to a single-line expression-bodied method, removing the explicit ArgumentNullException check for the categories parameter.
Improved validation and error handling in BenchmarkDotNet.Jobs.MetaMode. AddCategories now throws ArgumentNullException for null input. Unique method checks for null input and null categories, throwing exceptions as needed. Updated comments to clarify validation and merging logic.
Added extensive tests to JobCategoryTests.cs covering category merging, handling of null/empty categories, and category filtering via attributes and console arguments. Ensured categories are additive, invalid inputs are rejected, and introduced supporting test classes and attributes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants